선행으로 Either과 Option에 대한 이해가 필요합니다.
fp-ts가 3버전으로 개발하면서 기존의 io-ts를 fp-ts/schema가 대체하게 된다.
아직 레포가 생성된지 오래되지 않아 부족한 것이 있겠지만, 기존의 io-ts보다 훨신 진일보한 모습에 반해 파고들게 되었다.
zod에 대비해 뚜렷한 강점도 보이고 fp-ts를 기존에 사용하던 유저라면 같이 쓰기에 훨신 강력할 수 있을 것 같다.
사용하기에 앞서 먼저 사전 지식에 대해 설명하겠다.
AST 유형은 ADT(대수 데이터 유형), 즉 구조체 및 튜플과 같은 공용체에 대한 명세서이다.
Schema에서의 AST는 더 대략적이고 작은 부분을 다룬다.
밑에 설명할 Schema는 AST의 wrapper라고 이해하면 쉽다.
스키마는 유효성 검사, 변환을 하기 위해 작성된 명세서이며 이 스키마에 유효성 검사를 추가하던 데이터 변환을 추가하던 무조건 Schema가 리턴된다.
이 점이 zod랑은 다른 좋은 점이다. zod는 변환을 하거나 하면 다른 타입을 가지게 된다.
그로 인해 endomorphism를 활용한 관계를 활용할 수 있다. pipe를 통해 스키마를 이음으로써 간단하게 유효성 검사∙변환을 추가할 수 있다.
기본적으로 string, number, date 등등의 primitive한 타입들의 스키마는 다 만들어놓았으며 기본적인 필터들도 여러가지 만들어 놓았다.
const myDecoder = decode(mySchema)
스키마를 통해 디코더를 생성한다.
이 데이터를 받아 스키마의 명세에 따라 유효성 검사∙변환을 실행하는 주체이다.
type ParseResult<A> = Either<NonEmptyReadonlyArray<ParseError>, A>;
ParseResult는 Either이다.
성공했을시 스키마 명세를 거친 최종 값을 Right로 내뱉고 실패했을시 Left<NonEmptyReadonlyArray>를 내뱉는다.NonEmptyReadonlyArray이기 때문에 Left이면 첫번째 에러는 무조건 있다고 가정해도 된다.
배열형태로 에러를 내뱉는 이유는 구조체나 튜플 배열 형태를 유효성 검사를 할 수도 있으므로 에러가 여러개가 될 수 있기 때문.
type ParseError = Type | Index | Key | Missing | Unexpected | UnionMember;
ParseError는 여러 유형의 에러 종류에 대한 유니온 타입이다.
설명은 https://github.com/fp-ts/schema/blob/main/src/ParseResult.ts 여기에 주석으로 굉장히 잘 설명해놓았으니 가서 읽으면 이해하기 쉽다.
export interface Type {
readonly _tag: "Type";
readonly expected: AST.AST;
readonly actual: unknown;
}
export interface Key {
readonly _tag: "Key";
readonly key: PropertyKey;
readonly errors: NonEmptyReadonlyArray<ParseError>;
}
...
여기서 우리가 선언하는 대부분의 유효성 검사 실패는 Type이라는 것만 알아 두자.
그리고 expected에 아래 설명할 Annotation이 포함된다.
주석 스키마. 스키마에 여러 주석들을 달 수 있다.
이 주석은 기본적으로 선언해둔 여러가지가 있으며 각 명세가 실패하면 그 다음에 오는 주석을 적용시킨다.
https://github.com/fp-ts/schema#annotations 참고
export type Custom = unknown;
export declare const CustomId = "@fp-ts/schema/annotation/CustomId";
export type Message<A> = (a: A) => string;
export declare const MessageId = "@fp-ts/schema/annotation/MessageId";
export type Identifier = string;
export declare const IdentifierId = "@fp-ts/schema/annotation/IdentifierId";
export type Title = string;
export declare const TitleId = "@fp-ts/schema/annotation/TitleId";
export type Description = string;
export declare const DescriptionId = "@fp-ts/schema/annotation/DescriptionId";
export type Examples = ReadonlyArray<unknown>;
export declare const ExamplesId = "@fp-ts/schema/annotation/ExamplesId";
export type JSONSchema = object;
export declare const JSONSchemaId = "@fp-ts/schema/annotation/JSONSchemaId";
export type Documentation = string;
export declare const DocumentationId = "@fp-ts/schema/annotation/DocumentationId";
기본적으로 만들어놓은 주석은 위와 같다.
그러나 나만의 Annotation을 추가하기도 굉장히 쉬우므로 스키마가 실패했을시 다음 Annotation이 ParseError에 적용되는 것을 활용하여 나만의 Error Annotation을 만든 후 추가할 것이다.
import * as S from "@fp-ts/schema";
import * as E from "@fp-ts/core/Either";
import * as AST from "@fp-ts/schema/AST";
import * as PR from "@fp-ts/schema/ParseResult";
import * as O from "@fp-ts/core/Option";
import * as ID from "@fp-ts/core/Identity";
import { compose, flow, pipe } from "@fp-ts/core/Function";
import { LazyArg } from "@fp-ts/core/src/Function";
import { NonEmptyReadonlyArray } from "@fp-ts/core/ReadonlyArray";
위와 같이 내가 사용할 것들을 import 하였다.
interface ErrorData<T extends string> {
message: string;
code: T;
}
export const SchemaErrorId = "@fp-ts/schema/annotation/SchemaError" as const;
export const schemaError =
<T extends string>(errorData: ErrorData<T>) =>
<A>(self: S.Schema<A>): S.Schema<A> =>
S.make(AST.setAnnotation(self.ast, SchemaErrorId, errorData));
const unknownError: ErrorData<"unknown"> = {
code: "unknown",
message: "알 수 없는 에러가 발생했습니다.",
};
위와 같이 에러에 대한 선언을 해주고 S.make (Schema Make)와 AST.setAnnotation을 통해 기존의 AST에 대한 Annotation을 추가한다.
먼저 함수 합성을 도와줄 유틸을 하나 만들었다.
@fp-ts/core/Function의 compose를 flip시키면 제네릭 타입이 unknown으로 깨지더라... 그래서 새로 정의했음.
// 함수 합성을 순차적으로 시키는 함수 (compose의 revserse)
// const composeR = flip(compose);
// compose를 flip시키면 generic타입이 깨져서 새로 만듦
// composeR :: (a -> b) -> (b -> c) -> a -> c
const composeR =
<A, B>(ab: (a: A) => B) =>
<C>(bc: (b: B) => C) =>
flow(ab, bc);
나만의 꿀팁인데 주석으로 하스켈식 타입선언을 적어두면 [composeR :: (a -> b) -> (b -> c) -> a -> c] 특히 core한 함수인 경우 직관적인때가 많다.
composeR은 첫번째로 a -> b 함수를 받고 두번째로 b -> c 함수를 받은 다음 a를 받아 c를 리턴하게끔 함수를 순차합성하는 함수라는 것을 알 수 있다.
compose의 경우 역순차 합성이라 [compose :: (b -> c) -> (a -> c) -> a -> c] 처럼 되어있다.
https://fp-ts.github.io/core/modules/Function.ts.html#compose
[Function.ts
Function overview Added in v1.0.0 Table of contents instances getMonoid Unary functions form a monoid as long as you can provide a monoid for the codomain. Signature export declare const getMonoid: (Monoid: any) => () => any Example import { Predicate } fr
fp-ts.github.io](https://fp-ts.github.io/core/modules/Function.ts.html#compose)
이 compose의 특성을 이용해서 flow내에서 함수 합성을 하는 과정에서 아주 유용하므로 알아두는게 좋다.
그 후 error 태그에 대해 스위치문으로 각각에 행동을 정의해주면 된다.
나는 ParseError의 1depth 까지만 Type 에러가 있을 경우 Some, 아닌 경우 None을 리턴하는 방식으로 했는데, 더 깊은 뎁스까지 탐색하고 싶다면 재귀로 바꾸면 된다.
type AnnotationGetter<T> = (annotated: AST.Annotated) => O.Option<T>;
const getError = AST.getAnnotation<ErrorData<string>>(SchemaErrorId);
// ParseError를 받아 해당 에러의 ast를 통해 Annotation을 가져온다.
const getAnnotationX =
<A>(getter: GetAnnotation<A>) =>
(e: PR.ParseError): O.Option<A> => {
switch (e._tag) {
case "Missing":
case "Index":
case "Unexpected":
return O.none();
case "Key":
case "UnionMember": {
for (const error of e.errors) {
if (error._tag !== "Type") continue;
const annotation = getter(error.expected);
if (O.isSome(annotation)) return annotation;
}
return O.none();
}
case "Type": {
return getter(e.expected);
}
}
};
AST.getAnnotation은 fp-ts/schema/AST에 있는 함수로서 해당하는 AnnotationId를 사용해 schema의 ast로 부터 해당 Option을 추출한다. 해당 annotation이 있을지 없을지 모르므로 Option 값으로 던져준다. schema에 해당 annotation이 있으면 Some, 없으면 None으로 던져준다.
getAnnotationX에도 Option을 사용함으로써 값이 있음과 없음이 명백하다.
예를 들어 undefined 같은 값으로 정의해둔 에러가 있음과 없음을 구분하려면 이것이 에러 값이 undefined인지 에러가 없는 건지 구분할 수 없을 것이다.
여기서 일반적인 케이스인 경우에 첫번째로 검출된 에러만 필요하므로 첫번째 에러만 가져와서 넘겨주는 코드를 작성했다.
위에 4. ParseResult에서 정의된 것처럼 **실패했을 경우 Left<NonEmptyReadonlyArray// 지정한 AnnotationX에 대한 첫번째 Error의 Annotation을 가져온다.
// getFirstAnnotationX :: <A>AnnotationGetter<A> -> NonEmptyReadonlyArray<PR.ParseError> -> Option<A>
export const getFirstAnnotationX = flow(
getAnnotationX,
composeR((errors: NonEmptyReadonlyArray<PR.ParseError>) => errors[0]),
);
flow는 첫번째 함수의 인자를 받는다 가정하고 pipe처럼 이어준다.
composeR로 함수합성을 통해서 NonEmptyReadonlyArray<PR.ParseError> -> Option 이 중간다리 역할로 적용되어 합성되었다.원래라면 getAnnotationX는 2차 함수의 인자로 단일 ParseError을 받으므로 flow로 잇지 못했을 것이다.
그러나 그 중간다리의 역할로 composeR를 사용해 NonEmptyReadonlyArray -> ParseError 을 해주는 함수를 껴넣음으로써 잇는게 가증해진 것이다.함수형적인 패턴을 활용해 Lazy dependency injection이 이렇게나 쉽다는 것을 알 수 있다.
심지어 내가 원하는 부분에 툭 추가해서 injection 되는 데이터를 이렇게 바꿀 수도 있지 않은가
이제 내가 설정한 에러 외의 변환이나 키가 없거나 하는 예외케이스가 있을 수 있으니 그런 경우에 발생할 defaultError를 미리 받아 던지게 할 것이다.
// Right인 경우는 Schema에설정해 놓은 에러, Left인 경우는 defaultError로 구분할 수 있다.
export const firstErrorWithDefault = (defaultError: ErrorData<string>) =>
flow(getFirstAnnotationX(getError), ID.map(E.fromOption(() => defaultError)));
복잡성을 덜기 위해 getFirstAnnotationX의 1차 함수의 인자로 위에서 정의했던 getError 함수를 바인딩 해 주었다.
flow(getFirstAnnotationX(getError) 의 결과는 getError함수를 바인딩 해줌으로써 1차 함수의 결과가 나오고, flow의 첫번째 인자로 들어감으로써 나중에 입력 받겠다는 Lazy dependency의 효과를 지니게 되어 2차 함수의 결과를 가정하기 때문에 **Option**가 리턴된다.ID.map은 이럴 경우에 유용하다. 파이프의 앞쪽의 결과를 그대로(Identity) 사용할 경우 그 값에 적용할 함수를 넘겨주게 된다.
이게 흔히들 말하는 기본적인 functor(펑터)의 적용이다.
E.fromOption의 앞의 E는 Either의 약자이다.
즉 Option을 Either로 변환하면서 Right의 경우 Some, None인 경우에는 defaultError가 들어있는 Left<ErrorData>를 반환한다.근데 아까 말했듯이 Some인 경우에는 schema에 정의해둔 에러이므로 즉
right(schema의 에러) or left(defaultError) 가 되는 것이다
위의 함수를 사용해서 Left인지 Right인지 알면 내가 설정해둔 에러랑 디폴트 에러(설정하지 않은 예외 에러)랑 구분할 수 있다.
그러나 나는 에러는 하나로 퉁친 방식을 사용하고 싶으므로 Left값과 Right값을 합쳐준다.
// 위의 Right, Left 케이스를 하나로 합친다.
export const getFirstErrorWithDefault = flow(
firstErrorWithDefault,
compose(E.merge),
);
compose는 합성될 함수를 역순 (b -> c) -> (a -> b)로 받으므로 flow내에서 b -> c에 대한 함수의 내용을 미리 바인딩시켜 합성시킬 수 있다.
이 점에서 아주 유용하다.
E.merge를 사용하면 Either의 Left, Right의 값을 union 시켜서 받을 수 있다.
그렇다면 Left, Right 구분이 필요 없으므로 Either를 벗긴 값이 나온다.
즉 기존에 Either<ErrorData>을 리턴하던 것에서 ErrorData을 리턴하는 것으로 바꾼다.그렇게 하면 내가 설정한 에러랑 디폴트 에러랑 구분할 수 없지만 ErrorData내에 code가 있으므로 여전히 에러를 식별할 수 있기 때문이다.
이제 일일히 에러일때의 처리를 하기 귀찮으므로 손쉽게 ParseResult를 통해 에러를 가져와야 할 것이다.
여기서 나는 두가지의 함수를 작성했다.
하나는 Either로 리턴해서 에러가 발생하지 않았을때는 right(성공), left(실패)로 구분하는 함수.
또 하나는 굳이 내가 성공 값을 알 필요 없는 경우 Option으로 리턴해서 Some(실패), None(성공)로 구분하는 함수.
사실 첫번째 함수로만 사용해도 상관 없지만... 그냥 심심해서 두번째 것을 만들었다.
// 1. ParseResult가 성공시 Right 값 리턴, 실패시 Left
export const resultWithDefaultError = flow(
getFirstErrorWithDefault,
(errorFn) =>
<A>(r: PR.ParseResult<A>) =>
pipe(r, E.mapLeft(errorFn)),
);
// 2. ParseResult로 에러 여부 판별. none인 경우 에러가 없음을 의미한다.
export const result2ErrorWithDefault = flow(
getFirstErrorWithDefault,
(errorFn) =>
<A>(r: PR.ParseResult<A>) =>
pipe(E.getLeft(r), O.map(errorFn)),
);
이제 에러를 검출할 함수들을 다 만들었으니 스키마를 정의하고 디코딩을 해보겠다.
export const nameSchema = pipe(
S.string,
S.minLength(1),
schemaError({
code: "최소글자수미만",
message: "한글자 이상 입력해줘",
}),
S.maxLength(8),
schemaError({
code: "최대글자수초과",
message: "8글자 이하로 입력해줘",
}),
S.filter((s) => !/[^\w\s]/.test(s)),
schemaError({
code: "특수문자불가",
message: "특수문자는 사용할 수 없어",
}),
);
export const amountSchema = pipe(
S.string,
S.transform(S.number, Number.parseInt, String.toString),
S.filter((n) => !Number.isNaN(n)),
schemaError({
code: "올바른형태아님",
message: "숫자만 입력해줘",
}),
S.greaterThan(0),
schemaError({
message: "0원 넘게 입력해줘",
code: "최소금액미만",
}),
S.lessThan(100000000),
schemaError({
message: "1억원 미만으로 입력해줘",
code: "최대금액초과",
}),
);
구조체 형태, 튜플, 배열 형태로도 할 수 있지만 일단 귀찮아서 이렇게만 했다.
const amountFail1 = S.decode(amountSchema)("123456789");
result2ErrorWithDefault(unknownError)(amountFail1); /*?*/
// some({message: '1억원 미만으로 입력해줘', code: '최대금액초과'})
const amountFail2 = S.decode(amountSchema)("천만원");
result2ErrorWithDefault(unknownError)(amountFail2); /*?*/
// some({message: '숫자만 입력해줘', code: '올바른형태아님'})
const amountFail3 = S.decode(amountSchema)("0");
result2ErrorWithDefault(unknownError)(amountFail3); /*?*/
// some({message: '0원 넘게 입력해줘', code: '최소금액미만'})
const amountSuccess = S.decode(amountSchema)("12345678");
const amountSuccessCase =
resultWithDefaultError(unknownError)(amountSuccess); /*?*/
// right(12345678)
const nameFail1 = S.decode(nameSchema)("123456789");
const nameFailCase = result2ErrorWithDefault(unknownError)(nameFail1); /*?*/
// some({message: '8글자 이하로 입력해줘', code: '최대글자수초과'})
const nameFail2 = S.decode(nameSchema)("");
result2ErrorWithDefault(unknownError)(nameFail2); /*?*/
// some({message: '한글자 이상 입력해줘', code: '최소글자수미만'})
const nameFail3 = S.decode(nameSchema)("hihi!");
result2ErrorWithDefault(unknownError)(nameFail3); /*?*/
// some({message: '특수문자는 사용할 수 없어', code: '특수문자불가'})
const nameSuccess = S.decode(nameSchema)("42");
const nameSuccessCase =
result2ErrorWithDefault(unknownError)(nameSuccess); /*?*/
// none
O.isSome(nameFailCase); /*?*/
// true
O.isNone(nameSuccessCase); /*?*/
// true
if (E.isRight(amountSuccessCase)) amountSuccessCase.right; /*?*/
// 12345678
보시다시피 잘 작동한다.
default error 내가 정하지 않은 에러가 나는 케이스는 배열의 올바르지 않은 index에 접근하거나, 구조체 형태에서 키가 없는 프로퍼티에 접근하는 것과 같은 기타 예외 상황에 날 것이다.
타입클래스와 함수형의 패턴을 활용하면 이렇게 각각의 과정을 작성하며 변화하는 형태를 전부 남길 수 있다.
순수하기 때문에 보시다시피 함수 합성이 매우 쉽고 중간 과정을 전부 다른 함수의 합성 과정에 활용 할 수 있다는 장점이 있다.
특히나 dependency injection의 방식이 매우 우아하지 않은가?
객체지향형으로 작성했을시에는 처음에 dependency injection을 하고 나서 그 값을 바꾸려면 사이드 이펙트가 필연적으로 발생할 수 밖에 없는데 함수지향형으로 작성하면 depencency ineject 방식이 Lazy하므로 그 과정을 블록 교체하듯이 바꿀 수 있다.
fp-ts/schema이 zod와 비견되는 장점이라면.. 스키마 정의의 형태들을 훨신 재활용하기 좋고(필터, 조건, 에러 everything is ast) Option, Either 기반으로 작성되어 throw되지 않는 에러 검출에 특화되어 있으며 모든 ast를 직접 정의할 수 있으므로 무한한 확장이 가능하다는 것이다.
다만... 사용하려면 fp-ts를 사용하는 것은 필수이다.
fp-ts를 사용한 함수지향형적 어플리케이션을 작성한다면 아주 좋은 선택지가 될 것이다.
객체지향적으로 어플리케이션을 작성한다 하더라도 함수적 패턴을 사용하여 합을 맞추면 객체지향에서 발생하는 허점을 매꿔주고 효율성을 증대시킬 수 있다. 그러니 pipe, flow, Option, Either 개념 부터 시작해서 천천히 적용하시는 것을 추천한다.